Skip to content

Let an agent investigate the heap dump in a window somebody is watching - #2966

Merged
pyricau merged 27 commits into
mainfrom
investigate-root-cause
Aug 28, 2026
Merged

Let an agent investigate the heap dump in a window somebody is watching#2966
pyricau merged 27 commits into
mainfrom
investigate-root-cause

Conversation

@pyricau

@pyricau pyricau commented Aug 21, 2026

Copy link
Copy Markdown
Member

Shark Explorer's windows now answer MCP, so an agent investigates the heap
dump that is already open — the same tree, the same verdicts, the same notes — rather than a copy of its own,
and show puts what it is looking at on the screen of the person watching.

{
  "mcpServers": {
    "shark-explorer": {
      "command": "/Applications/Shark Explorer.app/Contents/MacOS/Shark Explorer",
      "args": ["--mcp-stdio"]
    }
  }
}

Why a server rather than shark-cli commands

Because a server can say no, and refusing is the whole mechanism — it works with any client, and nothing here
ever calls a model:

  • Every call takes a reason, enforced in AgentTool.call rather than only asked for in the schema, and
    it lands in the run's log beside the reads it caused. That makes an investigation something a person can
    follow afterwards instead of a conclusion they have to trust. (Traceability, not a quality gate — asking a
    model to explain itself doesn't make it right.)
  • A verdict needs a reason another reader can check, and one contradicting a verdict already recorded is
    refused with the list of what it disagrees with, the same way the window asks a person.
  • conclude is refused until the heap dump itself agrees one reference is at fault, and the refusal says
    which of the three ways that fails it is:
Not concluded. 1 step(s) between the last EXPECTED object and the first STUCK one have no verdict, so the
fault is at one of them and the chain doesn't say which: 0x12e9ed60 java.util.ArrayList. Until the chain names
one reference, a root cause would be a guess about which of those steps is at fault. Read the objects in the
unexplained stretch with describe_object, check whether anything else holds them with ways_held, and record
what you can defend with set_verdict.

An agent that has narrowed a chain to three unexplained steps cannot report a root cause, however confident
it is, and what it gets instead is the three objects to go and read.

The method — the LeakCanary method, as prose for a
model — is handed over twice, in the handshake and again with open_heap_dumps, because some clients drop the
handshake's instructions and a method nobody read is a method nobody followed.

Seventeen tools

open_heap_dumps, open_heap_dump, list_leaks, agent_log, chain_from_gc_root, describe_object,
ways_held, find_objects, dominator_tree, set_verdict, clear_verdict, read_notes, take_note,
show, conclude, list_devices, dump_heap — each one a read the window already does or a button above the
map, so the agent surface and the UI are the same internals with different interfaces. Parity is the rule in
both directions: a capability added to the window is a tool, and a tool is something the window can do. Which
is what agent_log is: the Agent logs screen had no tool behind it, and what has already been tried on a
heap dump is either the answer or the half of it not worth doing again.

And it is not only about leaks. list_leaks is the dump's own answer about what shouldn't be there;
dominator_tree and find_objects are where the memory went, which is a different question and the one asked
most after it. Checked end to end from a shell: a 146 MB dump of a real app taken off an API 29 emulator with
dump_heap, and the answer — 42 MB of loaded classes, and under that a Coil image cache holding a 4 MB bitmap
— out of dominator_tree and find_objects on the window it opened.

What it did, in the window

The window is where an agent's work is read, not only in a terminal it happened to be started from: the
Agent logs screen is one row per agent that has connected, and opening one is every call it made, in
words, with the sentence it gave for making it.

08:23:18  Read the chain to 0x12d368b8
          because: This is the one App leak: a MainActivity the app watched and whose mDestroyed is
          true. Reading the chain from a GC root.
08:23:45  Concluded about 0x12d00c30
          because: [...]
          Refused: Not concluded. Nothing on this chain of 4 steps is STUCK, so it points at no
          reference: the rules can only name one once something below it is known not to belong. [...]

A row leads where the call went — click the object and the window opens it — so reading what an agent did
and going to look at it are one move. The object, not the verb: a row is a sentence about a thing, and the
thing is what a reader wants to look at, so only that part is a link. Refused calls are rows too, in red, and
they are the half of a session worth reading afterwards, which is why what a call was about is worked out
before the call is answered rather than after.

Grouped by heap dump, this window's first and saying so, because an address is an address of one dump: a
session read against the wrong one is a screen of rows naming other objects. A group that isn't this window's
opens that dump instead of pretending to be about it, which is also what agent_log refuses across.

The rows are a file, ~/.shark-explorer/agents/sessions/*.jsonl, one per connection and the newest hundred
kept. One artefact with two readers: this screen, and the eval planned in notes/agent-eval.md, which now has
a session record to score a run from rather than prose to scrape.

It is also why the refusal for a contradicted verdict is prose now instead of a JSON array of the verdicts it
disagrees with — a refusal is the one answer on this surface a person reads, and that one landed on the screen
that exists to not show the protocol.

The transport, in two parts

A run publishes a loopback port and a token under ~/.shark-explorer/agents/<pid>.agent, and --mcp-stdio is
a mode of the same app binary that pipes stdio to it. Two parts because an MCP client can be configured with a
command and not with a port that changes every run. Deliberately not the socket shark:// links use: a link
is one line answered in a millisecond, this is a session held open for as long as an investigation takes.

Three ways in, one registry

MCP is the shape a client discovers, and it needs a client somebody configured. So the same tools are also
a command — --agent <tool> name=value …, with --agent-help generated from the registry — which reaches the
same window over the same socket, in 160 to 180 ms, and exits 2 with the refusal on stderr when the surface
said no. It is argument translation and nothing else: a refusal it prints was thrown by the handler that would
have refused an MCP client, so there is no second place where the rules live.

And a skill,
.claude/skills/shark-explorer/SKILL.md, because a tool nobody knows about is a tool nobody calls. It frames
the three cases an agent is in — something already open, a file to open, a device to dump — and points at
--agent-help and at the method the tools hand over rather than repeating either, which is what keeps it from
going stale. .claude/skills/ is where every client that reads the standard looks, so it is the project skill
here and the directory a user copies into ~/.claude/skills.

The costs are measured in notes/agent-surface.md: 21,123 characters of tools/list definitions paid every
turn over MCP against 14,594 characters of --agent-help paid only when read, which is 3.5% of a 200 k
context window for the whole surface either way.

How it was tested

57 unit tests and 5 UI tests, plus harness/start-harness.sh, which is how the thing this module is for actually gets tested:
it opens a window on a real dump and prints the command that throws an agent with no knowledge of this
repository at it, prompted with nothing but "find the root cause".

That run followed the method unprompted on leak_asynctask_o.hproflist_leaks, the chain, five
describe_object calls, ways_held, three defended EXPECTED verdicts, find_objects to check the
activity wasn't a singleton — and concluded MainActivity$2.this$0, with how the reference came to be set, how
to reproduce it, and four things it had not checked. The documentation quotes that session's log and the note
it left behind.

And a number for the whole thing

Every change to a tool description, a refusal or the method is a prompt change, and a prompt change is not
something anyone can review by reading it — JProfiler measured
theirs

and found one model going from 38/55 scenarios to 55/55 on the same tools with better descriptions. So
shark-explorer-eval gives this surface heap dumps whose answer key is known before the tools are asked
anything, harness/eval/run-eval.sh throws a client at one per run, and the score is a string comparison and a
count over the session file the server wrote while the agent worked. No model marks anything: a model
judging an answer is a second unverified opinion.

Scenario Model Right Calls Refusals
two-apart opus 1/1 15 0
two-apart sonnet 1/1 9 1
cache-never-evicts opus 1/1 19 0
cache-never-evicts sonnet 1/1 13 0
real-asynctask opus 1/1 28 0
real-asynctask sonnet 1/1 16 0

Six for six is a ceiling and therefore not much of a baseline; the row worth pointing at is sonnet on
two-apart, refused once for passing an argument set_verdict doesn't take, recovering on the next call, and
finishing in 9 calls against opus's 15. Harder scenarios are listed in notes/agent-eval.md, along with the
four ways a run turned out to be handed its own answer — all four found by running it rather than by reading
it, and the last of them is why open_heap_dumps now names the heap dump a run was pointed at while it is
still indexing. An agent that asks what is open, is told nothing is, and is not told where its own dump is has
one move left, which is to guess a path. Two of them did.

A link says which heap dump, and nothing else

shark://<window id>/<place> made every link die with the window it was copied from, which is most links a
day later — and most links in an agent's session log, since a session outlives the run that wrote it. Every
place a link can name belongs to the heap dump rather than to whatever is showing it, so the dump is the
identity. Two versions of that went too far before this one: the first carried the file's path beside the
name, 220 characters with four fifths of them a percent encoded path, and the second carried the window as a
refinement of the name. Both were paying, in every link, for a case that hardly ever comes up. Heap dump file
names are as good as unique — this app names every dump it pulls off a device after the process, its pid and a
random number, and LeakCanary names its own after the time of the dump — so the name is the whole of it:

shark://leak_asynctask_o.hprof/object?id=0x12d368b8

Four ways that goes, and the first is nearly always the one:

  • A window of this run has that heap dump open. That window is where the link goes, and nothing else is
    looked at.
  • None has, but this machine has had it open. HeapDumpPaths writes down where every heap dump that opens
    is, in ~/.shark-explorer/heap-dump-paths, the newest 200 kept — so the file opens, from where it was last
    seen.
  • Two heap dumps of that name, in two windows or on record at two paths. A link says nothing that tells
    those apart, so the reader is asked which, by path.
  • Nothing here knows that name — a link from somebody else's machine, or about a dump deleted or opened
    too long ago to still be on record. The reader is asked for the file. Asked, rather than left with silence:
    silence is the one answer that can't be told from the app having failed to start, and a link is usually
    followed from somewhere that can see neither way.

A window whose heap dump failed to open counts as not having it, in all four: the file it was given may
well be openable from where this machine last saw it — the case that found this was a run started with a
relative path, which the OS resolves against / for an app it launched — and landing a link on the window
that says so is the one outcome that helps nobody.

One dialog puts both questions, because both answers are a path: rows for the paths this machine knows of,
and a file picker under them for a heap dump that is at none of them. The window that asks comes to the front,
and keeps the sentence in its middle after the dialog is gone, so a question dismissed leaves the reason on
screen rather than an empty window with nothing to explain it.

Measured on a packaged build, every path a link can take:

19:20:41  A link asked window crdzrjc5 for Leaks(expandedGroups=[]) of leak_asynctask_o.hprof
19:22:58  A link asked for Starred of /Users/…/leak_asynctask_o.hprof, which is not open yet
19:28:00  A link to Leaks(expandedGroups=[]) of dup.hprof is asking: 2 heap dumps called dup.hprof are open.
19:28:44  /tmp/link-demo/emulator/dup.hprof was picked for dup.hprof, so its link goes to Leaks
19:31:29  A link to Leaks of never-opened.hprof is asking: No heap dump called never-opened.hprof is open
          here, and this machine has no record of opening one by that name. Choose the file, or say where it
          is in the link: &dump=/path/to/never-opened.hprof

— the window that had it open; the same link after that run had ended, looked up and opened; one name in two
windows, asked about and then answered by picking the emulator's copy; and a name nothing here has ever seen,
which asks for the file. &dump=/path/to/<name> is still there for a link that wants to say where the file
is, and it answers the question for the reader when it does.

Two things this unlocked: a --no-ui run answers show with a link now, having no window and the file all
the same, instead of saying there was nothing to hand back — and every Agent logs row about another heap
dump has a link to copy, where before there was nothing to send. On the agent surface the argument stays
heapDump, a file name, with window only where a name cannot answer.

🤖 Generated with Claude Code

pyricau and others added 27 commits August 21, 2026 18:34
Shark Explorer's windows now answer MCP, so an agent reads the dump that is
already open — the same tree, the same verdicts, the same notes — rather than a
copy of its own, and `show` puts what it is looking at on the person's screen.

The reason it is a server rather than a set of shark-cli commands is that a
server can say no, and refusing is the whole mechanism: it works with any client
and nothing here ever calls a model.

- Every call takes a `reason`, enforced in AgentTool.call rather than only asked
  for in the schema, and it lands in the run's log beside the reads it caused. An
  investigation becomes something a person can follow afterwards instead of a
  conclusion they have to trust.
- A verdict needs a reason another reader can check, and one that contradicts a
  verdict already recorded is refused with the list of what it disagrees with.
- `conclude` is refused until the heap dump itself agrees that one reference is
  at fault, and the refusal says which of the three ways that fails it is. An
  agent that has narrowed a chain to three unexplained steps cannot report a root
  cause, however confident it is.

The method — the LeakCanary method, as prose for a model — is handed over twice,
in the handshake and again with open_heap_dumps, because some clients drop the
handshake's instructions and a method nobody read is a method nobody followed.

Two parts to the transport: a run publishes a loopback port and a token under
~/.shark-explorer/agents, and `--mcp-stdio` is a mode of the same app binary that
pipes stdio to it, since an MCP client can be configured with a command and not
with a port that changes every run.

harness/start-harness.sh is how this actually gets tested: it opens a window and
prints the command that throws an agent with no knowledge of this repository at
it, prompted with nothing but "find the root cause". The documentation quotes
that run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dock reads the file name of the bundle a process was launched from, so every
window of the packaged app is a tile called "Shark Explorer" and two harness runs
are indistinguishable on screen. So clone the app image, name the copy after the
title, and set the two plist keys that name the menu bar — measured, all three
names come from different places, which is now in the guide.

The copy also has to live outside `build/compose`: another Compose task deletes
that app image, and a window whose bundle went away under it dies the way a
window launched from source does. `cp -c` clones, so 240 MB costs 80 ms and no
disk.

And two notes on what this surface should be: what MCP costs a client here
(3,300 tokens of tool definitions and 1,240 of method, measured rather than
guessed, against the 17,600 of GitHub's server), why a CLI and a skill are
adapters over one registry rather than second implementations of the rules, and
how to score whether an agent can actually solve a leak without a model doing
the scoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An agent reported NOT_LEAKING about an object the window called Expected, which
is one tool with two vocabularies: the person watching and the agent working
cannot check each other if the words change at the edge of the process. So the
enum is STUCK, EXPECTED and UNKNOWN now, and those are what the window shows,
what the files keep and what the agent surface takes and answers with.

Shark's LEAKING and NOT_LEAKING stop at the door. LeakFingerprint is the one
place that maps to LeakTraceObject.LeakingStatus, because a fingerprint has to be
the string LeakCanary computes.

A verdict file written before this has Shark's words in it, and its rows are
skipped with a line in the log saying which. That is deliberate rather than a
compatibility shim: this app is an alpha and the file is three columns of text.
Its header now says "verdict" too, since that is the word the window uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An investigation an agent ran and one a person ran are the same
investigation: the same tree, the same verdicts, the same notes. So what
an agent did belongs in the window, and in words — the *Agent logs*
screen is one row per agent that has connected, and opening one is every
call it made with the sentence it gave for making it.

A row leads where the call went, so reading what an agent did and going
to look at it are one move. A call about another heap dump is drawn and
leads nowhere: an address is an address of one dump.

The rows are a file, `~/.shark-explorer/agents/sessions/*.jsonl`, one per
connection and the newest hundred kept. One artefact with two readers,
which is why the reading half sits beside the writing half: this screen,
and the eval in notes/agent-eval.md, which now has a session record to
score a run from rather than prose to scrape.

The description of a call is worked out before the call is answered, so a
refused call still records what it was asking about — the refusals are
the half of a session worth reading afterwards, and one nobody can
follow up on is a dead end on the screen.

Which is also why the refusal for a contradicted verdict is prose now
rather than a JSON array of the verdicts it disagrees with: a refusal is
the one answer on this surface a person reads, and that one was three
verdicts and their reasons as raw protocol on the screen that exists to
not show it.
A row of a session read `Described 0x12d368b8`, which is the address the
agent wrote and not what this window calls that object anywhere else.
Now it reads `Described MainActivity 0x12d368b8`, from the same titleOf
the tabs are named by, so that a row and the tab clicking it opens are
recognisably one object.

Resolved in the window rather than recorded in the session file: an
address is what the agent said, and what it stands for is a read of the
heap dump this window has open — the read that names a tab. Which leaves
a call about another dump as the address it was, since naming it would
mean reading a dump nobody here has open.

An agent can also name an address this dump has no object at, and that
call is a row too, so the address is asked about before it is named:
titleOf throws on an object the graph hasn't got.
A chain that names a faulty reference is an investigation that is over, and
until now the only way to see that was to find the marked step: a real chain
is tens of steps, and the pane is scrolled to the last of them.

So the answer is also said where the eye starts, as a two line `Leak solved`
section above the list, and handed to an agent as `faultyReference` at the top
of the chain rather than left to be found by scanning the steps for isFaulty.

Both read the same spelling, `PathReference.leakLabel()`, which the leaks
screen and the note `conclude` writes now use too — four surfaces naming one
leak four ways is four leaks to whoever is grepping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The agent surface answered questions about a heap dump somebody else had
opened. Which meant an agent that needed the treemap, or a second dump, or
a dump off a device had one answer available: ask your human to click
something.

So the five tools that were missing:

- `dominator_tree`, the treemap without the pixels. Bounded in both
  directions, because the answer is a tree and every node is a read.
- `read_notes`, and `take_note` gaining `replace`. A surface that could only
  append leaves a wrong conclusion in the notes of the object it is wrong
  about, under a correction — and the next reader finds both.
- `open_heap_dump` and `list_devices`/`dump_heap`, which are the two buttons
  above the map.

The two that make a window answer once the dump can be *read*, not once the
window exists: everything else here is a read, so a window id handed over
mid-index refuses every call made with it. That needs a failed open to be
visible outside the composition, hence `ExplorerWindow.openProblem` — and
without it a file that was never a heap dump is a call that never comes back.

`AgentHeapDumps` stops being a `fun interface`; the tests get a fake of it
rather than a lambda. The place vocabulary moves to `AgentPlace.kt`, both
directions in one file, which is also what keeps `AgentTools` under detekt's
class size.

Re-measured what the surface costs a client: 16 tools, 18,880 characters of
definitions, up from 13,116 for eleven.
An agent could reach the tools only through a window somebody had already
opened, so a machine with no screen — a build server, a heap dump at the end
of an ssh session, anything driving an agent unattended — had no way in, and
even on a desktop the first answer was "ask your human to launch Shark
Explorer".

Two halves. `--mcp-stdio` with nothing running now starts a window, on the
heap dump its own command line named, and leaves it open for whoever comes
back to it. And `--no-ui` serves the tools from that process with no window
anywhere.

One code path with one call swapped rather than a headless mode beside the
windowed one: `AgentHeapDump.show` gained a return, and it is the only thing
that differs. Which is the point — the notes and the verdicts were never on
the screen, so a heap dump investigated with no window opens in one later
with the verdicts, the reasons and the conclusion already on it. `show`
answers with why it showed nothing rather than saying it did, because an
agent telling its human where to look is worse than saying nothing when
there is nowhere to look.

Two things found by driving it for real rather than by reading it:

A window opened for an agent publishes the run before its heap dump is
readable, so the agent's first move is to open the path it was pointed at —
and that used to make a second window and a second index of the same file.
Opening a path some window already has now hands back that window, which is
the opposite of what the button does, and on purpose: a person clicking
`Open heap dump…` twice is comparing two readings, an agent naming a path is
naming a heap dump. The headless side joins an open already in flight for the
same reason, which is also what lets a dump named on the command line be
indexed in the background instead of holding up the client's `initialize`.

And every bridge session ended with a `SocketException` trace on stderr —
the answers thread reading a socket this end had just closed — which is where
an MCP client collects a server's log, so a clean exit read as a crash. It
now knows the close was its own, and the test asserts stderr stays quiet.
`show` and `conclude` put a place on screen, which is right once and
wrong five times: raising a window over what somebody was doing is a
hand-over that happens now or not at all. A `shark://` link is the same
hand-over they can take with them, and the answer an agent writes lands
somewhere this app can't reach — a chat window, a pull request comment,
a bug report — so a link in that sentence is the difference between an
answer to take on trust and one to go and look at.

So `AgentHeapDump.show` answers with a `ShownPlace`: the link, or why
there was nowhere. One answer rather than two calls, because a link
names a window and so whether there is a link and whether anything was
shown are the same fact — a `--no-ui` run handing out a link would be
handing out an address nothing answers to.
How much of the window a note is worth is the reading of it against the
reading of the heap dump, and that changes with the note: a paragraph
about which of two caches this is wants four lines, and an argument
about a root cause with the chain quoted in it wants the half of the
window it takes to be read while looking at the chain. A number in the
code can only be right for one of them, so it is an edge, dragged the
way the edges between the panes already are.

The height is per window, beside the pane widths and for the same
reason: it is how somebody has set their desk up for the job in hand,
and a note that shrank as they clicked through tabs would be the window
rearranging itself. And it is never more than a share of the room the
tab has, because the edge is the only way back — a note dragged tall on
a big screen and then a window made small would otherwise put its own
edge past the bottom of the screen, and nothing but resizing the window
would bring it back.
Found by asking a real dump the question this surface's own docs
suggest: `find_objects` with `query`, which is what the window calls its
search box, matched nothing in particular and answered with the thirty
biggest objects out of 86,056 — a list of `ResourcesImpl` and bitmaps in
answer to a question about `BuildConfig`. Nothing said no, because
nothing read the argument.

Which is the failure this surface is built to make impossible: a wrong
answer that reads like an answer. So an argument no tool property
matches is refused, naming it and naming the ones the tool takes, and
the schema says `additionalProperties: false` so a client that validates
can catch it before the call rather than after.
The method stopped where the tools stop: it says isolating the reference
is not the root cause, and then leaves the reading of the code — the
only place a root cause can come from — as one clause in one bullet. An
agent that reads the framework on `main` and the app not at all writes
"nothing clears this in onDestroy" about a class the app doesn't ship,
which is confident, checkable-looking and wrong.

So the method now says which copy to read and how the dump itself says
which that is, and each claim in it was checked against a real dump
first: `android.os.Build$VERSION` carries `SDK_INT`, `RELEASE`,
`CODENAME` and `SECURITY_PATCH`; the app's `ApplicationInfo` carries its
process, its `dataDir`, the APK it was installed from, `minSdkVersion`,
a `targetSdkVersion=` in `seInfo` and `FLAG_DEBUGGABLE` in `flags`. And
`BuildConfig` is not in a dump at all — its constants compile into their
call sites, so the class never loads — which is why the app's own
version is something to ask for rather than look up.
A session recorded what was asked and never what came back, which is
fine for every tool that answers with data and wrong for the one that
answers with a conclusion. So the row that ends a session read
"Concluded about MainActivity" and the reference — the whole point of
the call — was only in the note it wrote.

`outcomeOfTool` reads it off `conclude`'s answer, the screen draws it
after an arrow, and the eval now has something to mark against an
answer key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An agent whose first call asked what was open, 2.6 seconds into a run
started on a heap dump, was told nothing was open and was not told the
path it had been started on. So it guessed one, opened a different heap
dump, and investigated that instead — confidently, and right through to
a conclusion about a dump nobody had asked it about.

A dump that is still indexing cannot be in `openHeapDumps`: a window id
is a promise that every tool given it answers, and one that isn't
readable can't keep it. But leaving the path out of the answer
altogether is worse than either, so `open_heap_dumps` now names what
this run was pointed at, and says to call `open_heap_dump` with it
rather than to go looking.

Both the run with a window and the run without one had the hole, since
a window exists for as long as its dump takes to index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every change to a tool description, a refusal or the method is a prompt
change, and a prompt change is not something anyone can review by
reading it. JProfiler measured theirs and found one model going from
38/55 scenarios to 55/55 on the same tools with better descriptions —
a change nobody would have predicted from the diff.

So: heap dumps whose answer key is known before the tools are asked
anything, a client per run, and a score that is a string comparison and
a count over the session file the server wrote while the agent worked.
No model marks anything, because a model judging an answer is a second
unverified opinion.

`shark-explorer-eval` is the dumps and the scoring — its own module
because writing a scenario needs `shark-hprof-test` in a main source
set, and that is not a dependency anything shipped should have.
`harness/eval/run-eval.sh` is the process handling between them.

Four things about it are only there because a run was handed its own
answer, and every one of those was found by running it: the dump's file
name, the client's working directory, the notes of the run before, and
the notes of the eval before that one. The fourth cost a day and is
written up in notes/agent-eval.md, because it read exactly like a model
getting a leak wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The *Agent logs* screen drew a bare `0x12d368b8` for most rows, and the
rows it did name were the only ones that led anywhere. Both came from
the same mistake: the window resolved the addresses itself, so it could
only speak for calls about the dump it happened to have open, and a
session spans every dump that was open while it ran.

So the name is recorded as the call is made, by the run that has that
dump open — `AgentTarget.about`, one small read per call — and the
screen shows what is written down. A row reads the same in any window
now, and a row about another heap dump opens that dump instead of doing
nothing. It only leads nowhere when the file has been deleted, which it
says by naming it.

Sessions written before this have no name against their calls, so
`subject` falls back to the address the agent typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`list_leaks` is the only tool whose place comes from which tool it is
rather than from an argument, so recording a name for it made its row
read "Listed the leaks Leaks" — the verb already says the whole of it.
A window is a heap dump, so the *Agent logs* screen of one is the agents
that read *that* dump: `AgentSession.heapDumpPaths` is what decides, and
the sessions about another dump are listed under `Other heap dumps` and
opened in a window of theirs. There is no window that isn't a heap dump
for them to be read in, and read against the wrong one they are rows of
addresses that mean other objects.

Which is also why naming an object goes back to the window, undoing the
`about` recorded on every call: the reader has the heap dump the session
is about, so it can resolve an address itself, and a session written
before the recording was added no longer reads differently from one
written after. The one row left as an address is a call that went on to
another dump, which says which file and opens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--agent <tool> name=value …` makes one call and prints the answer, and
`--agent-help` says what the calls are with no window, no heap dump and
no Gradle. For an agent whose client speaks no MCP, and for one that has
a shell and hasn't been configured with anything.

It is argument translation and nothing else: it builds a `tools/call` on
the loopback socket the run already publishes, so a refusal it prints
was thrown by the handler that would have refused an MCP client, and the
help is generated from the same registry `tools/list` answers from. Which
also means a call from a shell is not a slower call — measured at 160 to
180 ms, a JVM starting and a socket, against a heap dump that was parsed
and indexed once in the window somebody is watching.

What a process per call would otherwise lose is the session: a connection
is what gathers an MCP investigation, and a command per question would be
a row per call on the *Agent logs* screen. So the handshake carries an
optional session name after the token, `AgentSessionFile.continuing`
appends to the file that already has it, and a shell's calls default to
`cli<its pid>`. Checked at both ends, because it becomes part of a file
name — refused before anything is called at this end, served with a
session of its own and a line in the log at the other.

Exit codes carry the rest: 0 with JSON on stdout, 2 with the refusal on
stderr, 1 when there was nothing to answer it.
The *Agent logs* screen had no tool behind it, which broke the rule that every
screen has an agent equivalent — and it is one of the more useful screens to
have: an investigation somebody already ran is either the answer or the half of
the dump not worth doing again. `agent_log` lists the sessions that read this
dump, newest first, with what each concluded and how many calls were refused;
with `session=`, every call of one in order with the reason the agent gave.

Refused by name for a session that read another dump, because an address is an
address of one dump: reading it here would be rows meaning other objects. Which
is the rule the screen groups by, so both readers of a session file agree.

`dump_heap` said the garbage was collected "where the device is new enough",
which was vague and wrong: it is collected either way — `am dumpheap -g` from
API 27, and `JdwpGc` running the same collection in the process below that.

Detekt's LargeClass now excludes tests, the way TooManyFunctions already does,
and allows 700: a test class is as long as the story it walks, and AgentTools is
a registry whose length is tool descriptions rather than behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The screen listed this window's sessions and then "Other heap dumps", which
buried the one thing a reader needs to know about a session: which dump its
addresses belong to. Now it is "Agent log" over one group per heap dump, headed
with the file name — numbered when two dumps share one — and this window's group
first, saying so.

A row of a session was clickable end to end, so clicking the word "Described"
navigated. Only the object leads anywhere now, unless the call named none, where
the verb is the whole sentence and is the link itself.

Which also fixes "Listed the leaks Leaks": the window named a place derived from
the tool rather than from an argument, so the leaks screen's title landed after a
verb that already said it. It only names what the call itself said it was about,
which is one fewer heap dump read as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tools were reachable and undiscoverable: MCP needed a client someone had
configured, and `--agent` needed somebody to say it was there. So the skill —
`.claude/skills/shark-explorer/SKILL.md`, which is the directory every client
that reads the standard looks in, so it is the project skill here and the
directory a user copies into `~/.claude/skills`. It frames the three cases an
agent is actually in — something already open, a file to open, a device to dump
— rather than assuming a window is up, and it points at `--agent-help` and at
the method the tools hand over rather than repeating either.

The description is not leak-only, and neither is the surface any more: taking a
dump used to say "call list_leaks to see what the dump says about itself", which
points a dump somebody took because the app was using a gigabyte away from the
question. It names dominator_tree too. And `find_objects` now says that with no
className it is the biggest objects in the dump, which is what "what's big in my
heap" needs and which nothing said.

Checked by doing it: `--agent dump_heap` took a 146 MB dump of com.squareup off
an API 29 emulator in 90 seconds and opened it, and `dominator_tree` and
`find_objects` on that window found the class loader's 42 MB of classes and,
below it, a Coil image cache holding a 4 MB bitmap. The eval note has the plan
for scoring that as a scenario, including why the class loader answer is the
failure a "what's big" dump has to provoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A row of the Agent logs screen is a sentence with one link in it, and the link
was the whole sentence whenever the call named nothing: "Listed the leaks" and
"Read the agent log" were underlined verbs. A verb now stops where the thing
starts — "Listed the" and then *leaks* — which needs the words for a screen to
live beside the tool names rather than being taken from the tab titles, since a
tab says `Leaks` and a sentence says leaks.

Two calls had no link at all, and both are things a person does in this window:
dominator_tree with no object is the tree from its root, and find_objects with
no className is the object list unfiltered, which also stops that row reading as
"Searched for in com.squareup.hprof". AgentScreen is the words and the place as
one value, read by AgentTools.placeOrNull and by the screen, so a screen cannot
be reachable and unnamed or named and unreachable. Sessions already on disk have
no link for those two, so the place is worked out from the tool when the file
says none.

The call that asks which heap dumps are open answers with a list, so its row
unfolds into them: each dump a window away, the one this window has open marked
and leading nowhere. Which needs the answer recorded, like a conclusion — the
run that had them open has ended by the time anybody reads it.

And every session row leads somewhere now. One about another heap dump opens in
a window of that dump as before; the ones with no such window to be had — a dump
that has been deleted, or a client that read none — are read here rather than
being dead rows, since an address whose file has gone resolves to nothing in any
window there is, and the verbs, the reasons and the refusals are still what the
agent said. The group of a dump that has gone says so.

"Described" is gone: it read as the agent having written a description rather
than having asked what an object is. Every read now starts with Read, Looked,
Listed, Searched or Asked, and every write with Recorded, Took, Wrote, Showed,
Concluded, Opened or Dumped.
A link was `shark://<window id>/<place>`, which made it die with the window
— and that is most links a day later, and most links in an agent's session
log, since a session outlives the run that wrote it. Every place a link can
name belongs to the heap dump rather than to whatever is showing it, so the
dump is now the identity:

    shark://<file name>/<place>?<place>[&dump=<path>][&window=<id>]

The file name is the authority because it is what a person reads and types
and what every agent answer already carries; `dump=` is the normalized path,
since two `com.squareup.hprof` off two devices are two investigations and a
path is the only thing that can open a dump nothing has open. `window=` is
honoured while that window exists and ignored once it doesn't, rather than
turning the link into an error — which keeps the case the window id was for,
the same dump open twice being two readings of it compared.

Links already written into notes and session files still resolve: the last
step of `ExplorerWindows.windowFor` matches the authority against a window
id. A run claims a link only for a window it already has, never for a file
it could open, or every run would claim every link.

Two things this unlocks, and the reason to reverse it rather than live with
it. A `--no-ui` run answers `show` with a link now — no window and the file
all the same — instead of saying there was nothing to hand back. And every
*Agent logs* row about another heap dump has a link to copy, where before
there was nothing to send.

On the agent surface the argument is `heapDump`, taking a file name, with a
window id only where a name cannot answer.
A copied link was 220 characters, four fifths of them a percent encoded
path that says nothing to whoever reads it:

    shark://leak.hprof/object?id=0x12d368b8&dump=%2FUsers%2F…&window=zvphq4r3

The path was in there because a link has to outlive the run it was copied
from, and nothing else remembered where the file was. So something else
does: HeapDumpPaths writes down the path of every heap dump that opens,
under the id of the window it opened in, the newest 200 kept, and
following a link is a lookup. The same link is now

    shark://leak.hprof/object?id=0x12d368b8&window=zvphq4r3

What it costs is that a link works for as long as this machine remembers
the file rather than for as long as the file exists — and a link that has
been forgotten says so, and can still be given &dump=<path> by hand,
which is also the answer for a dump this machine has never opened.

Recorded under the window id rather than the dump, because that answers
both questions with one file per open: a window id resolves to the dump
it was showing, and a file name to the newest record with that name. So
`shark://<window id>/<place>` — the shortest a link can be, and what this
app used to write — opens the right heap dump again after every window of
it has gone, instead of finding nothing.

A run with no window records its dumps too, which is what makes the link
`show` hands back from a --no-ui run resolve for the next reader.
A link was carrying the window it was copied from, then the heap dump's whole path, and both were
answers to a question that hardly ever comes up: which of the heap dumps called that. Heap dump file
names are as good as unique — this app names every dump it pulls off a device after the process, its
pid and a random number, and LeakCanary names its own after the time of the dump — so a link that
says `shark://bug-4821.hprof/leaks` names one file on any machine that has it, and the long form was
paying for the rare case in every link.

So the name is the whole of what a link says, and the rare cases are asked about rather than encoded:

- A window has that heap dump open, which is nearly always. That window is where the link goes.
- None has, but this machine has had it open. It opens, from where it was last seen.
- Two heap dumps of that name, open or on record. The reader picks, by path.
- Nothing here knows the name — a link from another machine, a dump deleted or opened long ago. The
  reader is asked for the file, since silence can't be told from the app having failed to start.

One dialog puts both questions, because both answers are a path. `&dump=` is still there for a link
that wants to say where the file is, and it does the picking for the reader when it does.
A window keeps the file it was given even when opening it failed, so it went on claiming every link
about that name and every one of them landed on the error message. The file is usually openable from
where this machine last saw it — the case that found this was a run started with a relative path,
which the OS resolves against `/` for an app it launched — so a window whose heap dump failed to open
is no longer a window that has it, for links and for what this run claims from its peers.

And say plainly there is no file there, with the absolute path, rather than letting the parser report
the path as it was given: `/shark/shark-android/…/leak_asynctask_o.hprof` is what says the working
directory wasn't the checkout, where the relative path it was typed as says nothing at all.
@pyricau
pyricau merged commit 73697bb into main Aug 28, 2026
14 checks passed
@pyricau
pyricau deleted the investigate-root-cause branch August 28, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant